> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/jaypopat/cf_ai_duet/llms.txt
> Use this file to discover all available pages before exploring further.

# Terminal Sharing

> PTY management and vt10x terminal emulation for shared sessions

Duet's terminal sharing is built on two key technologies: **pseudo-terminals (PTY)** for shell I/O and **vt10x** for terminal state emulation.

## Terminal Architecture

The `Terminal` type in `/internal/terminal/terminal.go` wraps a PTY with terminal emulation:

```go theme={null}
type Terminal struct {
    vt   vt10x.Terminal
    ptmx *os.File
    cmd  *exec.Cmd
    mu   sync.Mutex

    width   int
    height  int
    workDir string

    subscribers map[chan struct{}]struct{}
    subMu       sync.RWMutex
    closed      bool

    lastRender string
    dirty      bool
}
```

### Component Breakdown

* **vt**: Terminal emulator that parses ANSI/VT100 sequences
* **ptmx**: PTY master file handle (connected to shell process)
* **cmd**: Shell process (bash, zsh, etc.)
* **width/height**: Terminal dimensions in columns/rows
* **workDir**: Working directory for shell (room's workspace)
* **subscribers**: Channels for broadcasting updates to clients
* **lastRender**: Cached string output (optimization)
* **dirty**: Flag indicating render cache needs refresh

## Pseudo-Terminal (PTY) Basics

A PTY creates a master-slave pair:

```
┌─────────────┐         ┌─────────────┐
│   Terminal  │         │    Shell    │
│   (Master)  │ ◄─────► │   (Slave)   │
│  /dev/ptmx  │         │  /dev/pts/N │
└─────────────┘         └─────────────┘
```

* **Master**: Application writes input, reads output
* **Slave**: Shell process thinks it's a real terminal

This allows capturing and sharing shell I/O between multiple clients.

## Terminal Initialization

```go theme={null}
func New(width, height int, workDir string) *Terminal {
    if width < 1 {
        width = 80
    }
    if height < 1 {
        height = 24
    }
    if workDir == "" {
        workDir = "/app"
    }

    return &Terminal{
        width:       width,
        height:      height,
        workDir:     workDir,
        subscribers: make(map[chan struct{}]struct{}),
    }
}
```

### Starting the Terminal

```go theme={null}
func (t *Terminal) Start() error {
    t.mu.Lock()
    defer t.mu.Unlock()

    // Create vt10x emulator
    t.vt = vt10x.New(vt10x.WithSize(t.width, t.height))

    // Get shell from environment or default to /bin/sh
    shell := os.Getenv("SHELL")
    if shell == "" {
        shell = "/bin/sh"
    }

    // Create command for shell
    t.cmd = exec.Command(shell)
    t.cmd.Dir = t.workDir
    t.cmd.Env = append(os.Environ(),
        "TERM=xterm-256color",
    )

    // Start PTY with specified dimensions
    var err error
    t.ptmx, err = pty.StartWithSize(t.cmd, &pty.Winsize{
        Rows: uint16(t.height),
        Cols: uint16(t.width),
    })
    if err != nil {
        return err
    }

    // Start background goroutine to read PTY output
    go t.readLoop()

    return nil
}
```

**Key Steps:**

1. Create vt10x emulator with terminal size
2. Determine shell executable (`$SHELL` or `/bin/sh`)
3. Set working directory to room's workspace
4. Set `TERM=xterm-256color` environment variable
5. Start PTY with `creack/pty` library
6. Launch background goroutine to read shell output

## Data Flow

### Input Flow (Client → Shell)

```go theme={null}
func (t *Terminal) Write(data []byte) (int, error) {
    t.mu.Lock()
    ptmx := t.ptmx
    t.mu.Unlock()

    if ptmx == nil {
        return 0, nil
    }
    return ptmx.Write(data)
}
```

When a client types:

1. Bubble Tea converts keystroke to bytes (e.g., `"a"` → `[]byte{0x61}`, Enter → `[]byte("\r")`)
2. `terminal.Write(data)` sends bytes to PTY master
3. PTY slave (shell) receives input as if from a real terminal
4. Shell processes command and writes output

### Output Flow (Shell → Clients)

```go theme={null}
func (t *Terminal) readLoop() {
    buf := make([]byte, 4096)

    for {
        n, err := t.ptmx.Read(buf)
        if err != nil {
            // Shell process exited
            t.mu.Lock()
            t.closed = true
            t.mu.Unlock()
            return
        }

        t.mu.Lock()
        if t.vt != nil {
            t.vt.Write(buf[:n])
            t.dirty = true
        }
        closed := t.closed
        t.mu.Unlock()

        if !closed {
            t.broadcast()
        }
    }
}
```

**Processing Steps:**

1. Read up to 4096 bytes from PTY master
2. Feed bytes to vt10x emulator (`t.vt.Write(buf[:n])`)
3. Mark render cache as dirty
4. Broadcast update notification to all subscribers

### vt10x Terminal Emulator

The vt10x emulator:

* Parses ANSI/VT100 escape sequences (colors, cursor movement, etc.)
* Maintains a 2D grid of cells (each with a character and style)
* Tracks cursor position and visibility
* Handles terminal modes (insert, wrap, etc.)

This allows converting raw shell output into a renderable terminal state.

## Publisher-Subscriber Pattern

### Subscription Management

```go theme={null}
func (t *Terminal) Subscribe() chan struct{} {
    ch := make(chan struct{}, 1)
    t.subMu.Lock()
    t.subscribers[ch] = struct{}{}
    t.subMu.Unlock()
    return ch
}

func (t *Terminal) Unsubscribe(ch chan struct{}) {
    t.subMu.Lock()
    delete(t.subscribers, ch)
    t.subMu.Unlock()
}
```

Each client subscribes when joining a room:

```go theme={null}
m.termUpdateCh = m.terminal.Subscribe()
```

### Broadcasting Updates

```go theme={null}
func (t *Terminal) broadcast() {
    t.subMu.RLock()
    defer t.subMu.RUnlock()

    for ch := range t.subscribers {
        select {
        case ch <- struct{}{}:
        default:
        }
    }
}
```

**Non-Blocking Design:**

The `select` with `default` ensures slow clients don't block the readLoop. If a client's channel buffer is full, the update is skipped (client will get the next one).

### Client Update Loop

In the Bubble Tea model:

```go theme={null}
func (m *Model) waitForTerminalUpdate() tea.Cmd {
    if m.terminal == nil || m.termUpdateCh == nil {
        return nil
    }
    return func() tea.Msg {
        <-m.termUpdateCh
        return terminalUpdateMsg{}
    }
}
```

When `terminalUpdateMsg` is received:

```go theme={null}
case terminalUpdateMsg:
    if m.terminal != nil {
        m.termContent = m.terminal.Render()
    }
    return m, m.waitForTerminalUpdate()
```

This creates a loop where the client:

1. Waits for terminal update notification
2. Calls `terminal.Render()` to get latest output
3. Updates UI model
4. Starts waiting again

## Rendering

### Render Method

```go theme={null}
func (t *Terminal) Render() string {
    t.mu.Lock()
    defer t.mu.Unlock()

    if t.vt == nil {
        return ""
    }

    // Return cached render if not dirty
    if !t.dirty && t.lastRender != "" {
        return t.lastRender
    }

    cols, rows := t.vt.Size()
    cursor := t.vt.Cursor()
    cursorVisible := t.vt.CursorVisible()

    var sb strings.Builder
    sb.Grow(cols * rows * 2)

    var prevFG, prevBG vt10x.Color
    var inStyle bool

    for y := 0; y < rows; y++ {
        prevFG, prevBG = 0, 0
        inStyle = false

        for x := range cols {
            cell := t.vt.Cell(x, y)
            char := cell.Char
            if char == 0 {
                char = ' '
            }

            isCursor := cursorVisible && x == cursor.X && y == cursor.Y

            fg := cell.FG
            bg := cell.BG

            if isCursor {
                // Swap fg/bg for cursor (reverse video effect)
                fg, bg = bg, fg
            }

            needsColorChange := fg != prevFG || bg != prevBG || (isCursor && !inStyle)

            if needsColorChange {
                if inStyle {
                    sb.WriteString("\x1b[0m")
                    inStyle = false
                }

                if fg != 0 && fg < 256 {
                    sb.WriteString(fgColor(fg))
                    inStyle = true
                }
                if bg != 0 && bg < 256 {
                    sb.WriteString(bgColor(bg))
                    inStyle = true
                }
                if isCursor && !inStyle {
                    sb.WriteString("\x1b[7m")
                    inStyle = true
                }

                prevFG, prevBG = fg, bg
            }

            sb.WriteRune(char)
        }

        if inStyle {
            sb.WriteString("\x1b[0m")
            inStyle = false
        }

        if y < rows-1 {
            sb.WriteString("\n")
        }
    }

    t.lastRender = sb.String()
    t.dirty = false

    return t.lastRender
}
```

### Rendering Optimizations

**1. Caching:**

```go theme={null}
if !t.dirty && t.lastRender != "" {
    return t.lastRender
}
```

If nothing changed since last render, return cached string.

**2. Run-Length Encoding:**

```go theme={null}
needsColorChange := fg != prevFG || bg != prevBG || (isCursor && !inStyle)
```

Only emit ANSI color codes when colors actually change, reducing output size.

**3. Pre-Allocated Buffer:**

```go theme={null}
var sb strings.Builder
sb.Grow(cols * rows * 2)
```

Pre-allocate buffer to avoid repeated allocations.

### Color Conversion

```go theme={null}
func fgColor(c vt10x.Color) string {
    if c < 8 {
        return fmt.Sprintf("\x1b[%dm", 30+c)
    } else if c < 16 {
        return fmt.Sprintf("\x1b[%dm", 90+(c-8))
    }
    return fmt.Sprintf("\x1b[38;5;%dm", c)
}

func bgColor(c vt10x.Color) string {
    if c < 8 {
        return fmt.Sprintf("\x1b[%dm", 40+c)
    } else if c < 16 {
        return fmt.Sprintf("\x1b[%dm", 100+(c-8))
    }
    return fmt.Sprintf("\x1b[48;5;%dm", c)
}
```

**Color Ranges:**

* **0-7**: Standard colors (black, red, green, yellow, blue, magenta, cyan, white)
* **8-15**: Bright colors
* **16-255**: Extended 256-color palette

### Cursor Rendering

```go theme={null}
if isCursor {
    // Swap fg/bg for cursor (reverse video effect)
    fg, bg = bg, fg
}
```

The cursor is rendered by reversing foreground and background colors at the cursor position, creating a visual highlight effect.

## Window Resizing

```go theme={null}
func (t *Terminal) Resize(width, height int) {
    t.mu.Lock()
    defer t.mu.Unlock()

    if width < 1 || height < 1 {
        return
    }

    t.width = width
    t.height = height
    t.dirty = true
    t.lastRender = ""

    if t.vt != nil {
        t.vt.Resize(width, height)
    }

    if t.ptmx != nil {
        pty.Setsize(t.ptmx, &pty.Winsize{
            Rows: uint16(height),
            Cols: uint16(width),
        })
    }
}
```

**Resize Synchronization:**

1. Update internal dimensions
2. Invalidate render cache
3. Resize vt10x emulator grid
4. Send SIGWINCH to shell via `pty.Setsize()`

This ensures programs running in the shell (vim, less, etc.) detect the new terminal size.

### Client Window Size Handling

When client terminal resizes:

```go theme={null}
case tea.WindowSizeMsg:
    m.width = msg.Width
    m.height = msg.Height

    _, terminalW, aiSidebarW, mainH := m.roomLayout()

    if m.terminal != nil {
        m.terminal.Resize(terminalW, mainH-4)
    }
```

The terminal is resized to fit the client's window, accounting for sidebars and UI chrome.

## Terminal Cleanup

```go theme={null}
func (t *Terminal) Close() error {
    t.mu.Lock()
    t.closed = true
    t.mu.Unlock()

    // Close all subscriber channels
    t.subMu.Lock()
    for ch := range t.subscribers {
        close(ch)
    }
    t.subscribers = nil
    t.subMu.Unlock()

    t.mu.Lock()
    defer t.mu.Unlock()

    if t.ptmx != nil {
        t.ptmx.Close()
        t.ptmx = nil
    }

    if t.cmd != nil && t.cmd.Process != nil {
        t.cmd.Process.Kill()
    }

    return nil
}
```

**Cleanup Sequence:**

1. Mark terminal as closed
2. Close all subscriber channels (notifies clients)
3. Close PTY master file descriptor
4. Kill shell process

This ensures clean shutdown when the last client leaves a room.

## Shared Terminal State

All clients in a room share:

* **Same vt10x instance**: Single source of truth for terminal state
* **Same PTY**: Input from any client goes to the same shell
* **Same render output**: All clients see identical terminal content

**Client-Specific:**

* **Subscription channels**: Each client has its own notification channel
* **Render timing**: Clients render independently based on their update loop

## Performance Characteristics

### Memory Usage

* **vt10x grid**: `width × height × sizeof(Cell)` ≈ 80 × 24 × 16 bytes = 30 KB
* **Render cache**: `width × height × 4` ≈ 80 × 24 × 4 = 7.6 KB (ANSI sequences add overhead)
* **Read buffer**: 4096 bytes per terminal

### Latency

* **Input latency**: Direct write to PTY (\< 1ms)
* **Output latency**:
  1. PTY read: kernel buffering (\< 1ms)
  2. vt10x parse: O(n) in output bytes (\< 1ms for typical output)
  3. Broadcast: O(clients), non-blocking
  4. Client render: Cached if no changes (\< 1ms)

Total round-trip latency: **\< 5ms** for typical interactions

### Scalability

Per room:

* **Terminal overhead**: \~40 KB + shell process
* **Per-client overhead**: \~16 bytes (channel in subscriber map)
* **Broadcast complexity**: O(n) where n = number of clients

With 100 clients in one room, broadcast is still \< 1ms.

## Keyboard Input Handling

Special key mappings in `/internal/ui/model.go`:

```go theme={null}
switch key {
case "enter":
    data = []byte("\r")
case "backspace":
    data = []byte{127}
case "tab":
    data = []byte("\t")
case "up":
    data = []byte("\x1b[A")
case "down":
    data = []byte("\x1b[B")
case "right":
    data = []byte("\x1b[C")
case "left":
    data = []byte("\x1b[D")
case "home":
    data = []byte("\x1b[H")
case "end":
    data = []byte("\x1b[F")
case "delete":
    data = []byte("\x1b[3~")
case "esc":
    data = []byte("\x1b")
default:
    if len(key) == 1 {
        data = []byte(key)
    } else if len(msg.Runes) > 0 {
        data = []byte(string(msg.Runes))
    }
}
```

These mappings convert Bubble Tea key events to the ANSI sequences shells expect.

## Error Handling

### Shell Exit Detection

```go theme={null}
n, err := t.ptmx.Read(buf)
if err != nil {
    // Shell process exited
    t.mu.Lock()
    t.closed = true
    t.mu.Unlock()
    return
}
```

When the shell exits, `readLoop` terminates gracefully.

### Write Failures

```go theme={null}
if ptmx == nil {
    return 0, nil
}
return ptmx.Write(data)
```

Writes to a closed terminal are silently ignored (returns 0 bytes written).

## Future Enhancements

Potential improvements:

* **Selective rendering**: Only send diffs to clients instead of full frames
* **Replay buffer**: Store terminal history for late joiners
* **Input queuing**: Buffer input during network lag
* **Compression**: Compress render output for slow connections
